Skip to content

feat(l1+evm+precompiles): add L1-backed zone db#698

Open
0xrusowsky wants to merge 41 commits into
mainfrom
rus/l1-aware-db
Open

feat(l1+evm+precompiles): add L1-backed zone db#698
0xrusowsky wants to merge 41 commits into
mainfrom
rus/l1-aware-db

Conversation

@0xrusowsky

@0xrusowsky 0xrusowsky commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

replaces #626

High-level goal

before this PR stack, Zones had two representations of Tempo L1 state:

  1. A semantic PolicyCache / PolicyProvider, populated from typed TIP-403 and TIP-20 events with RPC fallback. Custom ZoneTip20Token and ZoneTip403ProxyRegistry implementations used it to reproduce upstream policy behavior.
  2. A generic block-versioned raw storage cache keyed by (address, slot), used by TempoState and Zone system contracts for portal storage reads.

this duplicated policy semantics and left two L1-state paths that had to remain aligned in block anchoring, event decoding, invalidation, RPC fallback, and upstream behavior. The goal is to remove the semantic policy implementation from the EVM path and execute upstream Tempo code against one factory-installed L1-aware database. The database wraps the normal caller-provided EVM database and:

  • lazily reads the Tempo L1 anchor from local TempoState
  • serves TIP-403 registry storage from L1 at that anchor
  • overlays only the L1-owned transferPolicyId field in TIP-20’s packed slot, preserving Zone-local fields
  • delegates ordinary account, code, block-hash, and storage reads to local Zone state
  • rejects TIP-403 writes and strips mirrored TIP-20 fields from canonical state transitions

from upstream Tempo's perspective these remain ordinary EVM storage operations, but selected slots are virtualized to finalized L1 state. This makes upstream Tempo code the source of execution semantics rather than maintaining Zone-specific policy implementations or installing a separate storage provider around each precompile call.

EVM Database

database.rs adds AnchoredZoneDb, a database adapter installed by ZoneEvmFactory for every EVM created for payload building, canonical import, RPC calls, tracing, transaction-pool validation, and maintenance execution. The factory uses the alloy-evm database-context extension so the internal Tempo context can execute over AnchoredZoneDb<DB> while ZoneEvm continues to expose and return the original caller-provided DB.

this keeps Reth’s state ownership and commit path unchanged. Reads pass through the adapter during execution, while successful state transitions are validated and sanitized before Reth commits them through the original database. Inner database failures preserve their original error type; anchor, L1-read, and mirrored-write failures are propagated as typed custom EVM errors.

the adapter and native TempoState precompile share one execution-local anchor controller. It tracks whether execution is still at the parent Tempo anchor or has advanced to its direct child, rejects reads at an inconsistent anchor, rejects parent-anchor reads before advancement, and rejects duplicate or non-contiguous advancement. Noncommitting payload simulations restore the controller snapshot so they cannot leak anchor state into included transaction execution.

until TIP-20 policy storage moves fully into TIP-403, the adapter retains compatibility with the current packed layout. It reads the local packed word first, replaces only the L1-owned transferPolicyId field for execution, and restores the local field before canonical transitions are committed. TIP-403 account or storage mutations fail closed.

Precompile Execution

execution.rs now has one execution path for Zone-native and upstream Tempo precompiles. Every wrapper receives the same EVM configuration, storage-action recorder, and non-creditable-slot set. Whether a storage read is local or L1-backed is entirely a database concern rather than a choice between local and L1-aware precompile providers.

CallRules contains only Zone-specific admission behavior:

  • delegate calls are denied by default and explicitly enabled only for precompiles that support them
  • fixed-gas selectors are checked before execution
  • pure selector/caller admission returns either Continue or ABI-encoded revert data
  • the common wrapper applies calldata gas, reservoir handling, and fixed-price output consistently
  • admitted calls preserve the original calldata and caller when forwarding upstream

TIP-20, TIP-403, and TipFeeManager now execute the upstream Tempo implementations directly over ordinary EvmPrecompileStorageProvider storage. The remaining Zone-specific behavior is limited to privacy checks, bridge mint/burn authorization, read-only TIP-403 selectors, fixed gas, and delegate-call policy.

this removes ZonePrecompileStorageProvider, the separate local/L1-backed execution orchestration, and the custom L1-aware ZoneFeeManager. Tempo’s ordinary protocol fee manager now observes the same anchored policy state through the EVM database adapter.

Follow-up work

@socket-security

socket-security Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedalloy-primitives@​1.6.0 ⏵ 1.6.110010093100100
Updatedalloy-sol-types@​1.6.0 ⏵ 1.6.110010093100100

View full report

@0xrusowsky

Copy link
Copy Markdown
Contributor Author

cyclops audit

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

cc @0xrusowsky

Cyclops audit event published. View workflow run

Config: config: default, iterations: default, hours: default

@tempoxyz-bot

tempoxyz-bot commented Jul 17, 2026

Copy link
Copy Markdown

👁️ Cyclops Security Review

604908a

🧭 Finalizing · mode=normal · workers 3/3 done (0 left) · verify pending 0 · ending soon

Worker Engine Latest Status Status
pr-698-w1 claude-opus-4-8 🚨 Iteration 3 · Verify Done
pr-698-w2 gpt-5.5 🚨 Iteration 3 · Verify Done
pr-698-w3 gpt-5.6-sol 🚨 Iteration 3 · Verify Done

Findings

# Finding Severity Status
1 Zone TIP-20 privacy bypass: userRewardInfo/getPendingRewards reads are not access-gated, leaking any account's private balance High Iteration 1 · Verify
2 Zone TIP-20 balance/allowance privacy bypass via non-canonical (dirty-padded) address calldata high Iteration 2 · Verify
3 Enabling a token with a non-default L1 policy permanently stalls Tempo advancement High Iteration 1 · Verify
4 P2P followers can execute leader blocks with stale L1 policy storage High Iteration 1 · Verify
5 AnchoredZoneDb::sanitize_state rejects enableToken policy-id init, halting the zone for any bridged token with a non-default L1 transfer policy high Iteration 3 · Verify
6 Zone TIP-20 receive-policy blocked funds become unclaimable High Iteration 2 · Verify
7 Followers never receive the mutation barriers required by their consensus-critical L1 cache High Iteration 2 · Verify
8 ZoneBlockExecutor fee-token override reads the previous transaction, breaking non-default fee-token execution Medium Iteration 3 · Verify
9 Forgotten enabled tokens retain stale L1 transfer policy IDs indefinitely High Iteration 3 · Verify
⚙️ Controls
  • 🚀 Keep only 1 remaining iteration per worker after the current work finishes.
  • 👀 Keep only 2 remaining iterations per worker after the current work finishes.
  • ❤️ Let only worker 1 continue; other workers skip queued iterations.
  • 😄 Let only worker 2 continue; other workers skip queued iterations.
  • 🎉 End faster by skipping queued iterations and moving toward consolidation.
  • 😕 Stop active workers/verifiers now and start consolidation immediately.

📜 48 events

🔍 pr-698-w1 iter 1/3 [audit-general.md]
🔍 pr-698-w2 iter 1/3 [audit-ripple.md]
🔍 pr-698-w3 iter 1/3 [audit-invariants.md]
🚨 pr-698-w1 iter 1 — finding
🚨 Finding: Zone TIP-20 privacy bypass: userRewardInfo/getPendingRewards reads are not access-gated, leaking any account's private balance (High)
🔍 pr-698-w1 iter 2/3 [audit-invariants.md]
🔬 Verifying: Zone TIP-20 privacy bypass: userRewardInfo/getPendingRewards reads are not access-gated, leaking any account's private balance
📋 Verify: Zone TIP-20 privacy bypass: userRewardInfo/getPendingRewards reads are not access-gated, leaking any account's private balance → ✅ Verified
🚨 pr-698-w1 iter 2 — finding
🚨 Finding: Zone TIP-20 balance/allowance privacy bypass via non-canonical (dirty-padded) address calldata (high)
🔍 pr-698-w1 iter 3/3 [audit-invariants.md]
🔬 Verifying: Zone TIP-20 balance/allowance privacy bypass via non-canonical (dirty-padded) address calldata
📋 Verify: Zone TIP-20 balance/allowance privacy bypass via non-canonical (dirty-padded) address calldata → ✅ Verified
🚨 pr-698-w3 iter 1 — finding
🚨 Finding: Enabling a token with a non-default L1 policy permanently stalls Tempo advancement (High)
🔍 pr-698-w3 iter 2/3 [audit-ripple.md]
🔬 Verifying: Enabling a token with a non-default L1 policy permanently stalls Tempo advancement
🚨 pr-698-w2 iter 1 — finding
🚨 Finding: P2P followers can execute leader blocks with stale L1 policy storage (High)
🔍 pr-698-w2 iter 2/3 [audit-ripple.md]
🔬 Verifying: P2P followers can execute leader blocks with stale L1 policy storage
📋 Verify: P2P followers can execute leader blocks with stale L1 policy storage → ✅ Verified
🚨 pr-698-w1 iter 3 — finding
🚨 Finding: AnchoredZoneDb::sanitize_state rejects enableToken policy-id init, halting the zone for any bridged token with a non-default L1 transfer policy (high)
🔬 Verifying: AnchoredZoneDb::sanitize_state rejects enableToken policy-id init, halting the zone for any bridged token with a non-default L1 transfer policy
📋 Verify: Enabling a token with a non-default L1 policy permanently stalls Tempo advancement → ✅ Verified
🚨 pr-698-w2 iter 2 — finding
🚨 Finding: Zone TIP-20 receive-policy blocked funds become unclaimable (High)
🔍 pr-698-w2 iter 3/3 [audit-invariants.md]
🔬 Verifying: Zone TIP-20 receive-policy blocked funds become unclaimable
📋 Verify: AnchoredZoneDb::sanitize_state rejects enableToken policy-id init, halting the zone for any bridged token with a non-default L1 transfer policy → ✅ Verified
🏁 pr-698-w1 done
🚨 pr-698-w3 iter 2 — finding
🚨 Finding: Followers never receive the mutation barriers required by their consensus-critical L1 cache (High)
🔍 pr-698-w3 iter 3/3 [audit-ripple.md]
🔬 Verifying: Followers never receive the mutation barriers required by their consensus-critical L1 cache
📋 Verify: Zone TIP-20 receive-policy blocked funds become unclaimable → ✅ Verified
📋 Verify: Followers never receive the mutation barriers required by their consensus-critical L1 cache → ⏩ Dup
🚨 pr-698-w2 iter 3 — finding
🚨 Finding: ZoneBlockExecutor fee-token override reads the previous transaction, breaking non-default fee-token execution (Medium)
🔬 Verifying: ZoneBlockExecutor fee-token override reads the previous transaction, breaking non-default fee-token execution
🚨 pr-698-w3 iter 3 — finding
🚨 Finding: Forgotten enabled tokens retain stale L1 transfer policy IDs indefinitely (High)
🔬 Verifying: Forgotten enabled tokens retain stale L1 transfer policy IDs indefinitely
📋 Verify: ZoneBlockExecutor fee-token override reads the previous transaction, breaking non-default fee-token execution → ✅ Verified
🏁 pr-698-w2 done
📋 Verify: Forgotten enabled tokens retain stale L1 transfer policy IDs indefinitely → ✅ Verified
🏁 pr-698-w3 done

@0xrusowsky
0xrusowsky requested a review from klkvr July 17, 2026 16:44
Comment thread crates/evm/src/database.rs Outdated
Comment thread crates/evm/src/database.rs Outdated
Comment thread crates/evm/src/database.rs Outdated
Comment on lines +160 to +169
if let Some(account) = state.get(&TIP403_REGISTRY_ADDRESS) {
if account.info != account.original_info() {
return l1_write(TIP403_REGISTRY_ADDRESS, U256::ZERO);
}
for (slot, value) in &account.storage {
if value.is_changed() {
return l1_write(TIP403_REGISTRY_ADDRESS, *slot);
}
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah seems like a reasonable defense in depth.

}

let anchor = self.anchor()?;
let l1 = self.l1_storage(address, slot, anchor)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can return stale policy state after the first read. L1StateProvider::get_storage treats the latest cached value at or before anchor as valid, but policy events still update only PolicyCache, they do not write or invalidate the corresponding raw L1StateCache slots.

For example, after a slot is cached at N and changed on L1 at N+1, this read at N+1 returns the N value without making an RPC request. Cache warmth can then make nodes execute the same block with different policy state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice catch, was too aggressive when reverting L1 state cache changes to minimize the diff.

Restored a minimal version which addresses the soundness with e21f5a5 (this PR)

Comment thread crates/evm/src/database.rs Outdated
Comment on lines +185 to +186
if storage::merge_transfer_policy_id(U256::ZERO, value.present_value)
!= storage::merge_transfer_policy_id(U256::ZERO, observed.l1)

@0xKitsune 0xKitsune Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rejects the mandatory enableToken path whenever the token already has a non-default L1 policy.

ZoneTokenFactory::enable_token calls upstream TIP20Token::initialize, which writes transfer policy ID 1 into this packed slot. If the overlaid L1 field is anything other than 1, this comparison returns an error, causing the entire advanceTempo system transaction to fail and stalling block production.

Initialization needs an exception that discards/restores the mirrored field without treating the upstream default write as an attempted L1 mutation.

@0xrusowsky 0xrusowsky Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

totally missed it, nice catch. Rather than weakening the DB adapter with an initialization exception, enable_token now snapshots the anchored policy field, runs upstream initialization, and restores that field in the journal while preserving the adjacent initialized fields 18a7ded (this PR)

Comment thread crates/precompiles/src/lib.rs Outdated
Comment on lines +139 to +141
} else {
None
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replacement lookup drops the upstream RECEIVE_POLICY_GUARD_ADDRESS precompile. With L1 backed TIP403 execution, upstream TIP20 can enforce a receive policy, move blocked funds into the guard, and record a claim receipt internally.

However, users cannot call the guard's public claim or burnBlockedReceipt methods because this lookup returns None for its address, leaving those funds permanently locked. We should either explicitly reject receive policy resolution or find a different sollution.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i missed this too. It seems fine to simply enable it in the L2?

Comment on lines +35 to +40
fn decode_and_check<C: SolCall>(args: &[u8], check: impl FnOnce(C) -> CallCheck) -> CallCheck {
match C::abi_decode_raw_validate(args) {
Ok(decoded) => check(decoded),
Err(_) => CallCheck::Continue,
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Protected selectors must not continue when strict ABI validation fails. Upstream dispatch accepts some encodings that abi_decode_raw_validate rejects.

For example, setting a nonzero byte in an address word's upper padding makes this helper return Continue, while upstream still decodes the address and successfully serves balanceOf.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mmm i guess it makes sense. Is there a reason why we were using abi_decade_raw_validate here rather than the upstream abi_decade_raw?

if not the easiest fix seems to simply change the fn and continue delegating errors to upstream impl

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in 4f647c4 (this PR)

Comment thread crates/evm/src/executor.rs Outdated
block_number,
address_to_storage_value(pending_sequencer),
);
set_cache_slot(PORTAL_SEQUENCER_SLOT, current_sequencer.into_word());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we derive this slot (as well as other slots) from the sol! bindings rather than using a const?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will do it later in a followup, since it was already the case

Comment thread crates/precompiles/src/tip403_proxy/mod.rs Outdated
Comment thread crates/precompiles/src/ztip20/mod.rs
Comment thread crates/precompiles/src/ztip20/mod.rs
Comment thread crates/evm/src/zone_evm/mod.rs Outdated
) -> Result<TempoResult, ZoneEvmError<DB::Error>> {
let result = match execute(&mut self.inner) {
Ok(mut result) => {
if result.result.is_success()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverts and halts still return state that the block executor commits, including the nonce and Tempo fee transition, so sanitization cannot be success only. B

Before execution, collect_fee_pre_tx authorizes the fee token transfer through TIP403 and mutates that same token's balances/reward state. Those fee effects remain when the user call later reverts.

The returned touched token account contains the L1 overlaid policy bits, but this branch skips sanitize_state, and commit_transaction persists them into canonical Zone storage.

Should we sanitize every Ok(result) regardless of ExecutionResult? The reverted user-frame writes have already been rolled back, while the persistent fee/nonce transition still needs its mirrored fields removed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in 97b3d25 (this PR)

Comment on lines +171 to +173
if value.is_changed() {
return l1_write(TIP403_REGISTRY_ADDRESS, *slot);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking only is_changed() validates that the call did not modify the L1 value, but it does not remove that value from the returned transition. The adapter supplied the L1 value as both original_value and present_value, and revm commits every storage entry of a touched account.

For example, with canonical Zone slot 0 equal to 5 and the mirrored L1 value equal to 99, a policyIdCounter() transaction succeeds, sanitize_state returns Ok, and the touched registry account in result.state contains 99. Committing that result overwrites the Zone slot even though the transaction was read only.

cc @klkvr to double check if Im overlooking something here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in dcb4dea (this PR)

Comment thread crates/evm/src/test_utils.rs Outdated
Comment thread crates/precompiles/src/lib.rs Outdated
Comment thread crates/precompiles/src/execution.rs Outdated
Comment on lines +90 to +95
pub(crate) struct NoCallRules;
impl CallRules for NoCallRules {
fn is_delegate_call_allowed(&self) -> bool {
true
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you remind me why/when we need to delegate call with precompiles? We explictly restrict this upstream in Tempo via the tempo_precompile macro.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i saw that the L2 crypto precompiles (which are "pure") allowed delegate calls, so i thought u wanted to preserve behavior.

IMO it would be nicer to never allow it, as it would simplify impl

@0xrusowsky 0xrusowsky Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in 0abf462 (this PR)

Comment thread crates/precompiles/src/storage.rs Outdated
Comment on lines +99 to +103
/// The execution-local Tempo anchor used by the database adapter.
#[derive(Clone, Default)]
pub struct L1AnchorController {
state: Rc<Cell<L1AnchorPhase>>,
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there any way to avoid doing this or simplify? iiuc this is the main trade off vs ZoneStorageProvider which can read tempoBlockNumber from the state directly on every L1 read

in practice during execution zone's EVM only observes L1 state at a single anchor because advanceTempo is called at the beginning of the block so there must be a way to enforce this somehow

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simplified the state machine to a an Rc<Cell<Option<u64>>> and absorbed L1AnchorController into a L1State<P> type 2ebaf89 (this PR)

Comment thread crates/evm/src/database.rs Outdated
.inner
.execute_transaction_without_commit((tx_env, recovered));

self.evm_mut().reset_transaction_state();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to call it again here given that ZoneEvm::transact_raw handles it already?

@0xrusowsky 0xrusowsky Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was just being extra careful, but i think u are right. On the flip side, i think it doesn't hurt?

Comment thread crates/evm/src/executor.rs Outdated
Comment thread crates/evm/src/lib.rs Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants